Jerry's Log

ExceptionHandler in Java

contents

@ExceptionHandler 어노테이션은 견고한 API를 구축하기 위해 가장 중요한 개념 중 하나입니다. 앱이 터졌을 때 지저분한 Apache Tomcat HTML 에러 페이지를 반환하는 API와, 깔끔하고 예측 가능한 JSON 에러 메시지를 반환하는 전문적인 API를 가르는 결정적인 차이가 바로 여기에 있습니다.

Spring 프레임워크에서 @ExceptionHandler 는 비즈니스 로직을 try-catch 블록으로 어지럽히지 않도록 예외 처리 로직을 한 곳으로 모아주는(Centralize) 메커니즘입니다.

작동 방식, 확장 방법, 그리고 내부 동작 원리에 대해 알아보겠습니다.


1. 문제점: "Try-Catch 지옥"

REST API를 작성하고 있다고 상상해 봅시다. 사용자가 존재하지 않는 ID로 검색을 요청하면 서비스는 UserNotFoundException을 던집니다.

아마추어 방식 (@ExceptionHandler 없음):

@RestController
public class UserController {

    @GetMapping("/users/{id}")
    public ResponseEntity<?> getUser(@PathVariable Long id) {
        try {
            User user = userService.findById(id);
            return ResponseEntity.ok(user);
        } catch (UserNotFoundException e) {
            // 이 코드를 모든 메서드마다 일일이 작성해야 합니다!
            return ResponseEntity.status(HttpStatus.NOT_FOUND).body(e.getMessage());
        } catch (IllegalArgumentException e) {
            return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getMessage());
        }
    }
}

2. 첫 번째 단계: 컨트롤러 레벨 @ExceptionHandler

스프링은 @ExceptionHandler 애노테이션을 사용하여 catch 블록들을 별도의 메서드로 빼낼 수 있게 해줍니다.

@RestController
public class UserController {

    // 1. 깔끔한 비즈니스 로직
    @GetMapping("/users/{id}")
    public ResponseEntity getUser(@PathVariable Long id) {
        User user = userService.findById(id); 
        return ResponseEntity.ok(user); // try-catch가 전혀 필요 없습니다!
    }

    // 2. 예외 처리기 (Exception Handler)
    @ExceptionHandler(UserNotFoundException.class)
    public ResponseEntity handleUserNotFound(UserNotFoundException ex) {
        // '이 컨트롤러' 내부에서 UserNotFoundException이 발생하면 무조건 이 메서드가 호출됩니다.
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body("Error: " + ex.getMessage());
    }
}

3. 업계 표준: 전역 예외 처리 (Global Error Handling)

애플리케이션 전체에서 발생하는 예외를 한 번에 처리하려면, @ExceptionHandler@RestControllerAdvice(또는 @ControllerAdvice)라는 클래스 레벨 애노테이션과 함께 사용해야 합니다.

이것은 예외를 가로채는 전역 인터셉터(Global Interceptor) 역할을 합니다.

1단계: 표준 에러 응답 DTO 만들기

에러를 단순 문자열로 반환하지 마세요. 항상 일관된 JSON 구조를 반환해야 합니다.

public record ErrorResponse(
    int status,
    String error,
    String message,
    LocalDateTime timestamp
) {}

2단계: 전역 핸들러 만들기

@RestControllerAdvice // 스프링에게 "이 클래스를 모든 컨트롤러에 적용해라"라고 알려줍니다.
public class GlobalExceptionHandler {

    // 특정 커스텀 예외 처리
    @ExceptionHandler(UserNotFoundException.class)
    public ResponseEntity handleNotFound(UserNotFoundException ex) {
        ErrorResponse error = new ErrorResponse(
            404, "Not Found", ex.getMessage(), LocalDateTime.now()
        );
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
    }

    // 유효성 검사 에러 처리 (예: @Valid 실패 시)
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity handleValidationErrors(MethodArgumentNotValidException ex) {
        ErrorResponse error = new ErrorResponse(
            400, "Bad Request", "Invalid input data", LocalDateTime.now()
        );
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error);
    }

    // 예상치 못한 서버 에러를 위한 "최후의 보루 (Catch-All)" (NullPointerException 등)
    @ExceptionHandler(Exception.class)
    public ResponseEntity handleGenericException(Exception ex) {
        // 예상치 못한 에러는 반드시 로그로 남겨야 합니다!
        ex.printStackTrace(); 
        
        ErrorResponse error = new ErrorResponse(
            500, "Internal Server Error", "서버 측에 문제가 발생했습니다", LocalDateTime.now()
        );
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
    }
}

4. 내부 동작 원리 (Under the Hood)

스프링에서 예외가 발생하면, 이 예외는 스프링 웹의 핵심 교통경찰 역할을 하는 DispatcherServlet 으로 거슬러 올라갑니다(Bubbles up).

  1. DispatcherServlet이 날것의 예외(Raw Exception)를 낚아챕니다.
  2. 그리고 HandlerExceptionResolver라는 컴포넌트에게 묻습니다: "너 이거 어떻게 처리하는지 알아?"
  3. 스프링은 애플리케이션 컨텍스트를 뒤져 @ExceptionHandler가 붙은 메서드들을 스캔합니다.
  4. 해당 예외 타입과 일치하는 메서드를 찾아 실행하고, 그 반환값을 HTTP 응답으로 변환합니다.

5. 해결 우선순위 (어떤 핸들러가 이기는가?)

만약 UserNotFoundException을 던졌는데, UserNotFoundException 전용 핸들러도 있고 최상위 부모 클래스인 Exception 전용 핸들러도 있다면 어떻게 될까요?


6. 요약 비교 테이블

특징 기존 try-catch @ExceptionHandler (지역) @RestControllerAdvice (전역)
적용 범위 특정 메서드 하나 특정 컨트롤러 클래스 전체 애플리케이션 내 모든 컨트롤러
코드 중복 높음 (지저분함) 중간 없음 (가장 깔끔함)
관심사 분리 나쁨 (로직이 섞임) 좋음 매우 훌륭함
사용 시나리오 컨트롤러에서는 절대 사용 금지 에러가 특정 컨트롤러에만 국한될 때 API 개발의 업계 표준

상용 환경(Production)을 위한 개발자 체크리스트

  1. 스택 트레이스(Stack Trace)를 클라이언트에게 절대 유출하지 마세요: 500 Internal Server Error 발생 시 사용자에게는 일반적인 메시지만 반환해야 하며, 실제 오류의 세부 스택 트레이스는 디버깅을 위해 서버 로그에만 남겨야 합니다.
  2. @ResponseStatus 활용 (선택사항): 만약 ResponseEntity로 감싸서 반환하는 것이 귀찮다면, 그냥 DTO 객체만 반환하고 핸들러 메서드 위에 @ResponseStatus(HttpStatus.NOT_FOUND)를 붙여도 됩니다.
  3. 항상 '최후의 보루(Catch-All)'를 두세요: 예측하지 못한 버그가 사용자에게 추한 에러 페이지를 보여주지 않도록, Advice 클래스 맨 아래에 반드시 @ExceptionHandler(Exception.class)를 만들어 두어야 합니다.

references